Skip to content

Settings: hand-rolled sidebar chrome in place of the TabView - #199

Open
T0mSIlver wants to merge 5 commits into
mainfrom
settings-sidebar-chrome
Open

Settings: hand-rolled sidebar chrome in place of the TabView#199
T0mSIlver wants to merge 5 commits into
mainfrom
settings-sidebar-chrome

Conversation

@T0mSIlver

@T0mSIlver T0mSIlver commented Jul 27, 2026

Copy link
Copy Markdown
Owner

What

Replaces the five-tab TabView Settings window with a hand-rolled two-column
layout: a fixed 208pt sidebar of plain buttons, and a detail column that leads
with the pane's title and a one-line subtitle. Chrome only — every pane
renders exactly the content it did before, from the same call sites.

Stacked PR 1 of 2. #201 (settings-row-idiom) targets this branch and rewrites
the row/group primitives inside the panes — review this one first, and do not
delete this branch until #201 is retargeted (GitHub auto-closes children when a
base branch is deleted).

Why hand-rolled instead of NavigationSplitView / .sidebarAdaptable: both bring
a sidebar-collapse toolbar button that can only be removed with private-API
hacks, and their rows surface to accessibility as table cells. Plain AXButton
rows keep the window under our control and give the AX drills a stable element
with a stable identifier to press.

Details:

  • The Settings scene is kept, so openSettings(), Cmd-comma and
    restorationBehavior(.disabled) all keep working.
  • Width fixed at 780, height free (min 480 / ideal 560 / max ∞). Deliberately
    not .windowResizability(.contentSize): that resizes the window under the
    pointer on every tab switch. Each pane keeps its own ScrollView.
  • Transparent, full-size titlebar via a small WindowAccessor
    (SettingsWindowChrome), applied on viewDidMoveToWindow and on
    didBecomeKey — SwiftUI re-asserts its own titlebar config when the scene
    rebuilds the window.
  • Sidebar: NSVisualEffectView .sidebar material, .behindWindow, 28pt top
    inset clearing the traffic lights, 34pt rows with an r8 selection fill, 22×22
    tinted icon tiles, About pinned to the bottom over a v{version} ({build})
    footer.
  • New: Sources/localvoxtral/Settings/{SettingsSidebarView,SettingsPaneHeader,SettingsWindowChrome}.swift.
    SettingsTab gains title/subtitle/systemImage/tint and the two AX
    identifiers. Raw values are unchanged — the scripts key off them.

Script updates (mandatory, not optional polish)

Both AX drills clicked button "<tab>" of toolbar 1 of window 1, which no longer
exists. The probe moved to scripts/lib/ax-probe.swift (shared by ui-smoke.sh
and capture-readme-assets.sh) and gained:

  • --press <identifier> — find by AXIdentifier, AXUIElementPerformAction
    kAXPressAction; --title <text> is accepted as an AXTitle fallback, and the
    probe prints which route it used, so the log answers the "does
    .accessibilityIdentifier surface on a .plain Button?" question empirically.
  • --scope <identifier> — assert needles ONLY inside that subtree. This closes a
    real hole: sidebar row labels are AXStaticText and could vacuously satisfy a
    pane assert. Every assert_tab now has to match inside
    settings.pane.<tab>.
  • --window <title-substring> — both fallbacks match by shape (an AXButton
    with this title; the first AXScrollArea), so they must not wander into another
    window: the enrollment sheet alone contains two ScrollViews. Both drills pass
    Settings. A hint that matches no window searches all of them and says so, so
    a window that stops carrying its title degrades loudly rather than matching
    nothing.
  • AXIdentifier added to the element tuple and to the --dump-on-fail output.
  • ui-smoke.sh dumps the tree once before the per-tab assertions and asserts the
    pane scope is reachable at all (a settings.pane.* identifier or a scroll
    area). Losing AX identity used to be six 10s timeouts each blaming its own
    needle; it is now one named failure, and the pass line records which route the
    run used.
  • capture-readme-assets.sh: its three tab tables are indexed together, so a
    length mismatch would have written one tab's screenshot into another tab's
    file. Length guard, loud exit.

No assert was weakened; the pane asserts are strictly stronger than before.

Proof

  • Unit suite green — ./scripts/remote-build.sh
Test Suite 'localvoxtralPackageTests.xctest' passed at 2026-07-28 00:05:49.941.
	 Executed 2021 tests, with 2 tests skipped and 0 failures (0 unexpected) in 75.714 (75.862) seconds

Re-run after the review hardening commit (da5d653), focused then full:

Test Suite 'SettingsTabTests' passed at 2026-07-28 01:13:48.197.
	 Executed 6 tests, with 0 failures (0 unexpected) in 0.002 (0.002) seconds

Test Suite 'Selected tests' passed at 2026-07-28 01:15:06.946.
	 Executed 2021 tests, with 2 tests skipped and 0 failures (0 unexpected) in 72.147 (72.239) seconds

CI build-test on this branch: pass (4m7s).

Build is warning-free for the new/changed files (the only warnings in
.build/last-remote.log are the pre-existing ClaudePluginInstallService.swift:226
try? one and two in ClaudeHookPublisherTests).

New test — Tests/localvoxtralTests/SettingsTabTests.swift (6 cases, part of the
2021 above): the primary + meta sidebar arrays cover SettingsTab.allCases
exactly once with no overlap and no duplicates; every case has a non-empty
title/subtitle/symbol/identifier; subtitles are single-line sentences; and both
identifier schemes are pinned to the literal settings.tab.<rawValue> /
settings.pane.<rawValue> strings the scripts hardcode, as are the raw values
themselves.

  • AX drill: dispatched twice on this branch
    (runs 30309569624
    and 30310237279).
    Both stopped before any AX assertion ran, at the drill's pre-launch guard:
PASS: TCC preflight has Accessibility and Screen Recording grants.
PASS: Defaults domain snapshot captured and smoke run forced to external mode with onboarding completed.
FAIL: Existing app instance did not quit before smoke launch; cannot launch a fresh instance.

That is the runner's GUI session having a localvoxtral instance that would not
quit (it is the owner's personal Mac, ~00:20 local). The drill deliberately
refuses to kill it rather than force-quitting someone's running app, and I did
not weaken that guard to get a green. The AX selectors are therefore verified
by code-reading only — please re-dispatch ui-smoke.yml on this branch once the
Mac is free.
capture-assets.yml (which would have produced screenshots and
the same evidence) was deliberately NOT dispatched: it pkills the running app
and switches the Mac to dark mode, which is not something to do to an owner who
is evidently at the keyboard

  • LLM lanes: not run, and not required — this PR changes window chrome and
    two GUI drill scripts. Nothing in the diff touches prompts, model pins,
    sampling, the polish request shape, or anything else that reaches the model
    (scripts/ci/llm-lane-filter.sh matches nothing here).

UI change — what was verified, and what was not

Verified here: compiles clean under Swift 6.2 strict concurrency; the full unit
suite; the sidebar/pane data model by test; and the drill selectors by
code-reading plus a
deliberate belt-and-braces in the probe: --press falls back to the row's AXTitle
and --scope falls back to the window's first AXScrollArea, each printing which
route it took. So the drill answers "does .accessibilityIdentifier surface on a
.plain Button / on a pane container?" on its first real run without being
able to break on the answer. The fallback keeps the anti-vacuous property: the
sidebar rows and pane header are outside the pane's scroll area.

Not verified — needs the owner's eyes on a real window (./scripts/try-pr.sh <this PR>):

  1. Whether the .behindWindow sidebar material vibrates or renders flat in this
    window. If it is flat, the documented one-line fallback is .withinWindow in
    SettingsSidebarBackground.
  2. Whether the transparent/full-size titlebar sticks across close → reopen of the
    Settings window (the didBecomeKey re-apply is there for exactly that). If it
    ever fights the scene irrecoverably, the documented fallback is to keep the
    standard titlebar.
  3. That 28pt of top inset actually clears the traffic lights on this display
    scale, and that the 780×560 default is comfortable for the longest pane
    (Text Processing).

The tabbed Settings window had run out of room: five tabs of dense rows in
a 580x420 frame, with no place to say what a pane is for. This replaces the
TabView with a two-column layout — a fixed 208pt sidebar of plain buttons
and a detail column that leads with the pane's title and a one-line
subtitle. Chrome only: every pane renders exactly the content it did before.

Hand-rolled rather than NavigationSplitView/.sidebarAdaptable on purpose.
Those bring a sidebar-collapse toolbar button that can only be removed with
private-API hacks, and their rows surface to accessibility as table cells.
Plain AXButton rows keep the window under our control and give the AX
drills something stable to press.

The Settings scene is kept (openSettings(), Cmd-comma and
restorationBehavior(.disabled) all ride on it). Width is fixed at 780 and
height stays free: .windowResizability(.contentSize) would resize the
window under the pointer on every tab switch.

Both AX drills had to move with it: `button "<name>" of toolbar 1 of
window 1` no longer exists. The probe is now shared
(scripts/lib/ax-probe.swift) and gained --press <identifier> and --scope
<identifier>. Scoping closes a real hole rather than being cosmetic: pane
content asserts now have to match inside settings.pane.<tab>, so a sidebar
row's AXStaticText label can no longer satisfy them.
…ute matched

The open question is whether SwiftUI surfaces .accessibilityIdentifier on a
container in a plain-styled hierarchy. If it does not, every scoped pane
assertion in the drill would fail for a reason that has nothing to do with the
pane. The fallback is the window's first AXScrollArea, which keeps the property
the scope exists for: the sidebar rows and the pane header sit outside it, so a
navigation label still cannot satisfy a pane assertion. The success line names
the route it took, so one drill run answers the question.
… pane scope

Review follow-ups on the drill's two shape-matched fallbacks. Both of them —
an AXButton with this title for --press, the first AXScrollArea for --scope —
searched every window the app had open. The enrollment sheet alone contains two
scroll views, so a presented sheet could have answered a pane assertion, and any
window with a "General" button could have answered a tab press. `--window
<title-substring>` restricts the search; both drills pass "Settings". A hint that
matches nothing searches everything and says so, so a window that stops carrying
its title degrades loudly instead of silently matching nothing.

ui-smoke now also dumps the tree once before the per-tab assertions and asserts
that the pane scope is reachable at all — either a settings.pane.* AXIdentifier
or a scroll area. Losing AX identity was previously six 10s timeouts, each
blaming its own needle; it is now one named failure, and the pass line records
which of the two routes the run used.

capture-readme-assets.sh: the three tab tables are indexed together, so a
mismatch would have written one tab's screenshot into another tab's file. Length
guard, loud exit.

Copy link
Copy Markdown
Owner Author

Manual UI review — CI artifact

Tested the exact prebuilt artifact with ./scripts/try-pr.sh 199 (CI run 30313553536), using Computer Use against the native window.

Coverage: all five panes, managed and External URL endpoint states, light/dark appearance, scrolling, and close → reopen.

Findings

  1. The new chrome works in the important states. The 780×560 window is comfortable, the 28pt inset clears the traffic lights, selection is obvious, About stays pinned, the pane header remains stable, and close → reopen preserved the selected pane while keeping the title hidden/full-size chrome applied.
  2. The sidebar material reads as a flat solid fill on this host, in both light and dark appearance. It still separates the columns clearly, so this is not unusable, but I did not see the “vibrated”/translucent sidebar effect the PR calls out. I would try the documented .withinWindow fallback before merging if matching the standard macOS sidebar material is a goal.
  3. The parent alone still looks rough in Text Processing: the inherited fixed 128pt label column wraps long labels into 3–4 lines and leaves the control/help column ragged. This is not introduced by Settings: hand-rolled sidebar chrome in place of the TabView #199, and Settings: rows read like macOS System Settings rows #201 materially improves it, but I would not ship Settings: hand-rolled sidebar chrome in place of the TabView #199 without the child row rewrite.
  4. The colored purple pill at the extreme top-left of a few captures is macOS’s Computer Use/screen-recording indicator, not localvoxtral UI.
Screenshots (8)
General — light
Dictation — light
Endpoints — managed
Endpoints — External URL
Text Processing — long labels
About — light
About — dark/material check
About — after close/reopen

Copy link
Copy Markdown
Owner Author

SSH enrollment flow — artifact hand-test

I exercised the SSH enrollment UI in the CI-built #199 artifact via try-pr.sh, from the empty form through the one-time-token sheet, config/remote confirmation previews, verification, uninstall, and the final notes.

UI findings

  • The pre-enrollment row is cramped in this layout. “Remote Claude Code over SSH” wraps to three lines, the existing-host status/actions are squeezed together, and the two enrollment fields are very narrow. This is the clearest example I found of the inherited fixed label/control split fighting content that really wants a full-width/stacked row.
  • The sheet’s safety steps work: both “Insert into ~/.ssh/config” and “Run on SSH host” stop at an explicit inline Cancel/Confirm stage. I opened both previews and cancelled them.
  • UX concern: pressing Enroll… immediately creates the host credential before any local config or remote setup has happened, while the terminal action is simply Done. It is easy to read Done as cancelling/finishing the wizard even though the host remains enrolled as “not seen yet.” Copy such as “Create enrollment…” / “Finish later”, or a short note that the credential has already been created, would make that state transition clearer.
  • The lower half is extremely dense. Verify, Uninstall, and Notes are correct and useful, but the small low-contrast text in a fixed 560×520 sheet is difficult to scan. Breaking Notes into shorter labeled disclosures would help.

No SSH config was inserted and no remote command was run. The temporary host registry was restored byte-for-byte and both test tokens were invalidated before these screenshots were uploaded.

Screenshots

Row states: empty → partial → ready

SSH enrollment overview
Name entered; Enroll remains disabled
Valid form; Enroll enabled

Enrollment sheet and confirmations

One-time token and setup steps
SSH config confirmation preview
Remote setup confirmation preview

Verification, uninstall, and notes

Verification commands
Uninstall and notes
Final notes

# Conflicts:
#	Sources/localvoxtral/SettingsView.swift
The hand-test on the field Mac (PR #199 review) saw `.behindWindow` render
as a flat solid fill in both light and dark appearance — this window cannot
vibrate what is behind it, so the standard sidebar look never appeared.
`.withinWindow` is the documented fallback and is what actually produces a
material here.
@T0mSIlver

Copy link
Copy Markdown
Owner Author

Merged origin/main (21 commits: the remote-context campaign #216#221) into this branch.

Conflict: only SettingsView.swift, and only in body — main added rows to the old TabView, this branch replaced it with the sidebar + detailColumn switch. Resolution keeps the branch's structure and ports main's one call-site change into it (AboutSettingsPane(settings:viewModel:), for the new dogfood Build row). Everything else in the file auto-merged: the cmux join toggle + ClaudeCmuxPasswordSettingsRow, the per-host persistentForwardRow ("Keep the tunnel open" + status + Retry), pluginUpdatePanel, ClaudeEnrollmentStepResults, the rejection hint, the enrollment sheet's "Update later" section and its no-alias copy-only path, and the About Build row all landed unchanged — diffing the merged file against main's shows only this branch's own structural edits. localvoxtralApp.swift auto-merged (main's edits are all in AppDelegate, this branch's in the Settings scene).

Also applied the one design fix from the hand-test above (finding 2): the sidebar's NSVisualEffectView now blends .withinWindow instead of .behindWindow, which is the fallback the code comment already named for exactly this symptom — flat solid fill in both appearances.

Proof: ./scripts/remote-build.sh — build clean, unit suite Executed 2593 tests, with 2 tests skipped and 0 failures. Pushed as cc052f9.

@T0mSIlver

Copy link
Copy Markdown
Owner Author

Final review (opencode/GLM-5.2, post-merge state): no findings above nit level. The merge resolution was explicitly verified — the diff of the merged SettingsView.swift against main's is exactly the sidebar rewrite, with every main-side addition (cmux rows, persistent-forward row, plugin-update panel, enrollment step results, About Build row, AboutSettingsPane signature) present and intact.

Six nits, dispositions:

  1. ax-probe press fallback can double-press on a failed AXPress — accepted (AXPress failure on a healthy button is a transient AX race; restructuring the probe risks the drills).
  2. --scope scroll-area fallback could bind to an attached sheet's ScrollView if a future drill opens one — the probe's own comment names this; no current drill does.
  3. .withinWindow blends against an opaque hosting layer, so the result is a flat material rather than true vibrancy — consistent with the cc052f9 field report and comment; noted in case the visual bar moves later.
  4. ui-smoke's non-fatal scope check costs ~60s when globally broken — deliberate per its comment, for per-needle diagnostics.
  5. Sidebar order is not pinned by tests — being adopted on Settings: Context pane splits Claude/SSH context out of Text Processing #223 (stack top), where the final five-pane order lives.
  6. Pane switch resets pane-local @State (validation errors) — inherited from the TabView shape, not a regression.

T0mSIlver added a commit that referenced this pull request Aug 4, 2026
The coverage tests compare Sets, so an accidental reorder passed every
test while moving rows users have muscle memory for. Pinned here at the
stack top, where the final five-pane order lives.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant